Skip to content

feat(shadow-requests): add experimental shadow_config wrapper - #250

Merged
hsadhvani merged 9 commits into
mainfrom
aic-3514-shadow-config
Nov 3, 2026
Merged

hsadhvani merged 9 commits into
mainfrom
aic-3514-shadow-config

Conversation

@hsadhvani

@hsadhvani hsadhvani commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Why

Shadow requests let a team try a candidate AI Config variation on real traffic without serving it, and compare it on the normal AI metrics. The SDK owners asked for this to be merged as experimental (TESTING.md §0). Earlier versions (#241, #247) either added an invocation hook to core or changed config() for everyone; this one is an opt-in wrapper, so core behavior is unchanged.

Summary

Adds launchdarkly_ai_server.experimental.shadow_requests with shadow_config(), run_shadow(), ShadowTask and ShadowResult. shadow_config() takes the same arguments as config(), evaluates the flag once with variation_detail, runs the served variation through the normal config() path, and starts the sampled shadows after it succeeds (in the background, or through on_task). Core gets three small generic seams: a private ConfigInstance._evaluate, a private _parse_variation split out of extract_variation, and optional run_id, extra_track_data and span_attributes on execute_and_track. Also adds the API report, a README section and the shadow-requests changelog scope.

Testing approaches

  • Added tests that plain config() never calls variation_detail and runs no shadows, even when the flag has a shadow configuration.
  • Added tests that the served response, usage and events match config() for invoke, stream and the AG-UI stream.
  • Added tests for shadow selection by rule or fallthrough, expiry, malformed entries, disabled or missing variations, missing handlers and sticky sampling.
  • Added tests for background and deferred modes, timeouts, failures, shutdown cancellation, shadow event fields, span attributes and usage IDs.

Feature flags

N/A


Note

Overview
Adds experimental shadow requests under launchdarkly_ai_server.experimental.shadow_requests: shadow_config() mirrors config() but, after the served variation succeeds, runs sampled candidate variations from flag shadowConfiguration (rule/fallthrough slot, expiry, sticky sampling). Responses are discarded; telemetry reuses the primary runId with isShadow and related fields. Shadows run in the background (30s default, cancelled on shutdown()) or via on_task + run_shadow() for worker queues.

Plain config() is unchanged—no shadows and no variation_detail. Core changes are small shared hooks: _fetch_variation / _parse_variation, ConfigInstance._evaluate and _judge_stream (stream/AG-UI start shadows after judges), make_track_data, and optional run_id, extra_track_data, span_attributes, and feature_flag_event=False on execute_and_track. Docs, shadow-requests changelog scope, and a new API report accompany the module plus a large test_shadow_requests.py suite.

Reviewed by Cursor Bugbot for commit 133a542. Bugbot is set up for automated code reviews on this repo. Configure here.

@hsadhvani
hsadhvani marked this pull request as ready for review October 29, 2026 20:02
@hsadhvani
hsadhvani requested a review from a team as a code owner October 29, 2026 20:02
@hsadhvani
hsadhvani requested a review from XieX October 29, 2026 20:02

@XieX XieX left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice approach. Core never calls out to shadow code, and plain config() runs the same path as before: the client suite passes locally with no existing test edits. The new guard around _select_tasks covers the one robustness gap I'd noticed. Three small, non-blocking comments inline.

class _Evaluated(_InternalConfigInstance):
"""Runs ``config()``'s invoke and stream on a variation that is already evaluated."""

def __init__(self, source: ConfigInstance, variation: dict[str, Any]) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_Evaluated forwards ConfigInstance's constructor fields by hand. If ConfigInstance gains an option later, the served run under shadow_config() would silently run without it, and its output would stop matching config(). The parity tests wouldn't catch that, since they only exercise today's options.

A small test comparing the parameters of ConfigInstance.__init__ with the fields forwarded here would make that drift fail loudly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated 🙂

return self._variation


async def _evaluate(config_key: str, context: LDContext) -> _Evaluation:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the client half of extract_variation (init_client, get_client, to_ld_context and the awaitable handling), so a later change there, such as to lazy init or context conversion, won't reach shadow_config().

Could core have a private helper that fetches the raw value, with a generic with_reason flag, called by both extract_variation and this function? It doesn't name the feature, so core stays unaware of shadows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated 🙂

primary._execute_stream(user_input, context, variables, history, sink)
) as source:
async for event in source:
if event.get("type") == "done":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: when streaming, shadows start here, before judges run (_stream_events runs them after this generator finishes). On invoke they start after _invoke returns, which is after run_judges has finished. Neither delays the caller, but with slow judges, invoke shadows start noticeably later than stream ones. Fine if intended; a short comment saying so would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated 🙂

@XieX XieX left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more finding from cross-checking against the JS SDK, inline below.

usage=parse_usage({}), track_data=task.track_data, error="timeout"
)
except Exception as err:
return ShadowResult(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On failure, ShadowResult.track_data is task.track_data, which has only the six shadow fields (runId, isShadow, primaryVariationKey, shadowRequestId, ownerKind, ownerId). There is no configKey, variationKey, version, or model and provider names. That applies here, on the timeout path above, and when no handler matches (select_handler raises inside the try). On success it's B's full tracking data. So a worker running deferred tasks can't tell from the result which variation failed.

The JS SDK returns B's full tracking data on every path (makeRunTrackData plus the shadow fields). Here it would need the same payload execute_and_track builds inline. A small private helper in tracking.py, used by both, would keep them from drifting. The tests at lines 749 and 766 only check isShadow, so asserting configKey and variationKey on a failed result would cover it.

Minor, if you want the SDKs aligned: the no-handler error reads "Handler for provider X not found" here and "No handler for provider X" in JS.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 17a4487. Configure here.

@hsadhvani

Copy link
Copy Markdown
Contributor Author

These have been fixed, and I had Claude confirm. Christie, could you take a look next week?

From Claude: Fixed Christie's comments in 17a4487 and 082f1cf, merged with main in c90a6ec.

  • A failed, timed-out or unrouted shadow returns B's full track_data.
  • Stream and AG-UI shadows start after judges.
  • Tasks carry the conversation id, and run_shadow runs under exactly that id.
  • Shadow spans skip the feature_flag event.

The no-handler message stays core's existing error.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. The four open threads from Christie all look addressed at this head and can be resolved. On semver: the only root API change is four new keyword-only, optional parameters on execute_and_track, and griffe finds no breaking changes against main. config() still calls only variation(), and plain AG-UI streams are unchanged. Locally at c90a6ec, these all pass: pytest packages/client (2093 passed), mypy, ruff check, ruff format --check and scripts/api_report.py --check.

Two small runtime fixes are inline. I'd like the first one before merge, because it can record a timed-out shadow as a success.

async def timed(*args: Any) -> Any:
deadline = asyncio.timeout(timeout_s)
try:
async with deadline:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check deadline.expired() after the async with block too. asyncio.timeout only raises TimeoutError if the CancelledError propagates. A handler (or a provider client) that catches the cancellation for cleanup and then returns a value exits the block normally, and its late result is tracked as generation:success. A result that arrives after the timeout should be discarded and tracked as a timeout:

async with deadline:
    result = await handler(*args)
if deadline.expired():
    raise _ShadowTimeout("timeout")
return result

I confirmed the asyncio.timeout behaviour on its own, but didn't run it end to end through run_shadow. A test with a handler that swallows CancelledError would pin it down.

except _ShadowTimeout:
return _failed(task, "timeout")
except Exception as err:
return _failed(task, str(err))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the exception has no message (a bare TimeoutError() is common from HTTP clients), str(err) is "", so a worker that checks if result.error treats the failure as a success. Suggest str(err) or type(err).__name__.

assert sr._running
await shutdown()

async def test_failing_shadow_never_reaches_the_caller(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this test and the timed-out test below only check that generation:success appears among the served call's events. Comparing them to the events from the same call with no shadow configured would prove the shadow left them unchanged.

@hsadhvani

Copy link
Copy Markdown
Contributor Author

Thanks Jeff, all fixed. A result after the deadline now counts as a timeout, and an empty error uses the type name.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, approving. Both fixes are in at d083e5b. A handler that swallows the cancellation and returns late is now a timeout (tracked as one error and no success), and an exception with no message reports its type name. Both are tested. The rate-1 rule is in too.

Locally at this head, these all pass: pytest packages/client (2096 passed), mypy and ruff. I also checked with a throwaway test that a stream abandoned before done and a failed served call with on_task set both start no shadow and hand over no task. Optional: tests for those two cases would lock that behaviour in. My earlier optional note on the failing-shadow tests still stands, but it isn't needed for merge.

My threads can be resolved.

@hsadhvani
hsadhvani merged commit 8b81491 into main Nov 3, 2026
12 checks passed
@hsadhvani
hsadhvani deleted the aic-3514-shadow-config branch November 3, 2026 21:50
@github-actions github-actions Bot mentioned this pull request Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants